Python Mock:やさしい入門編 - パート2
著者:Leonardo Giordani - 27/09/2016
はじめに
前回の記事では、Pythonのmock について紹介しました。mock とは、他のオブジェクトを模倣し、単体テスト中に外部システムを置き換えるプレースホルダーとして機能するオブジェクトのことです。Mockオブジェクトの基本的な動作、return_value属性とside_effect属性、そしてassert_called_with()メソッドについて説明しました。
この記事では、残りの assert_* メソッドと、モックオブジェクトが受け取ったコールをチェックするための興味深い属性について簡単に説明します。そして、テストにおいて非常に重要なトピックであるパッチングについて紹介し、その例を示します。
その他のアサーションと属性
mockライブラリの公式ドキュメントには、その他のアサーションとして assert_called_once_with()、assert_any_call()、assert_has_calls()、assert_not_called() が挙げられています。assert_called_with() がどのように動作するかを理解していれば、 他のアサーションがどのように動作するかを理解するのに困ることはないでしょう。モックオブジェクトが使用された後の履歴について、どのようにアサートするかについては、必ずドキュメントを確認してください。
これらのメソッドに加えて、Mockオブジェクトはいくつかの便利な属性を提供していますが、そのうちの2つの属性については最初の投稿ですでに確認済みです。残りの属性は、予想通りほとんどが呼び出しに関するもので、call_count、call_args、call_args_list、method_calls、mock_callsと呼ばれています。これらの属性については公式ドキュメントでもよく説明されていますが、ここではmethod_callsとmock_callsの2つの属性を紹介したいと思います。これらの属性には、mock上でコールされるメソッドの詳細なリストが格納されており、call_args_list属性にはすべてのコールのパラメータがリストアップされています。
Mockオブジェクト上で呼び出されたメソッドはそれ自体がモックであることを忘れてはいけません。したがって、まずメインのMockオブジェクトにアクセスして呼び出されたメソッドの情報を取得し、次にそれらのメソッドにアクセスして受け取った引数を取得することができます。
パッチング
mockは、オブジェクトが外部からクラスやインスタンスを受け入れるときに、テストに導入するのがとても簡単です。その場合、説明したように、モッククラスをインスタンス化して、できたオブジェクトをシステムに渡すだけでよいのです。しかし、ライブラリがインスタンス化する外部クラスがハードコードされている場合、この単純なトリックは機能しません。この場合には、本物のオブジェクトの代わりにMockオブジェクトを渡すチャンスがありません。
これはまさに、パッチング(Patching) によって対処されるケースです。テストフレームワークにおけるパッチングとは、グローバルに到達可能なオブジェクトをモックで置き換えることを意味します。これにより、コードの一部がホットスワップされている(つまり、実行時に置き換えられている)にもかかわらず、コードが変更されずに実行されるという目標を達成することができます。
ウォームアップ例
まず、非常に簡単な例を挙げてみましょう。パッチングを最初に理解するのは複雑ですから、些細なコードで学ぶのがよいでしょう。まだお持ちでない方は、前の記事で紹介した手順でテスト環境 mockplayground を作成してください。
与えられたファイルに関する情報を返す簡単なクラスを開発したいと思います。このクラスは、ファイル名(相対パス)を指定してインスタンス化します。
簡潔にするために、クラスのTDD開発のすべてのステップをお見せしません。TDDでは、テストを書いてからコードを実装することになっていますが、これでは細かすぎることもあるので、何も考えずにTDDのルールを使わないようにしてください。
クラスの初期化のためのテストは
code: python
from fileinfo import FileInfo
def test_init():
filename = 'somefile.ext'
fi = FileInfo(filename)
assert fi.filename == filename
def test_init():
filename = 'somefile.ext'
relative_path = '../{}'.format(filename)
fi = FileInfo(relative_path)
assert fi.filename == filename
それをtests/test_fileinfo.pyファイルに入れることができます。テストを通過させるコードは以下のようなものになります。
code: python
import os
class FileInfo:
def __init__(self, path):
self.original_path = path
self.filename = os.path.basename(path)
ここまでは何も新しい機能を導入していませんでした。今度は、get_info()関数に、ファイル名、クラスがインスタンス化されたときのオリジナルのパス、ファイルの絶対パスのタプルを返してほしいのです。
すぐにテストの書き方に問題があることに気づきます。なぜなら、テストで呼び出された関数の結果は、テスト自体のパスに応じて変化することになっているからです。テストの一部を書いてみましょう
code: python
def test_get_info():
filename = 'somefile.ext'
original_path = '../{}'.format(filename)
fi = FileInfo(original_path)
assert fi.get_info() == (filename, original_path, '???')
ここで、'????' という文字列は、ファイルの絶対パスをテストするために何か賢明なものを置くことができないことを強調しています。
この問題を解決するにはパッチを当てることです。関数はファイルの絶対パスを取得するために何らかのコードを使用することがわかっています。そこで、テストの範囲内で、そのコードを別のコードに置き換えて、テストを実行することができます。置き換えたコードは結果がわかっているので、テストを書くことが可能になります。
つまりパッチとは、ある範囲でグローバルにアクセス可能なモジュールやオブジェクトをモックに置き換えてほしいとPythonに伝えることです。この例でどのように使用するか見てみましょう。
code: python
from unittest.mock import patch
def test_get_info():
filename = 'somefile.ext'
original_path = '../{}'.format(filename)
with patch('os.path.abspath') as abspath_mock:
test_abspath = 'some/abs/path'
abspath_mock.return_value = test_abspath
fi = FileInfo(original_path)
assert fi.get_info() == (filename, original_path, test_abspath)
Python 2を使用している場合、pipでmockモジュールをインストールしたことを覚えておいてください。そのため、import文はfrom mock import patchとなります。
with文で囲まれているので、パッチが適用される状況がよくわかります。この文の中では、モジュール os.path.abspath は、関数 patch によって作成された abspath_mock という名前のモックに置き換えられています。最初の記事で標準的なモックで行ったように、この関数に return_value を与えてテストを実行することができます。
テストを通過させるコードは
code: python
class FileInfo:
def get_info(self):
return self.filename, self.original_path, os.path.abspath(self.filename)
明らかに、テストを書くためには、os.path.abspath関数を使用することを知っていなければなりません。したがって、パッチを当てることは、TDDにおいては、ある意味で「純粋ではない」行為なのです。純粋なOOP/TDDでは、オブジェクトの外部の振る舞いにのみ関心があり、内部構造には関心がありません。しかし、この例は、現実世界の問題に対処しなければならないことを示しており、パッチングはそれを行うためのきれいな方法なのです。
パッチング・デコレーター
unittest.mock モジュールからインポートした patch() 関数は非常に強力で、関数デコレーターとしても使用することができます。この方法で使用する場合は、装飾された関数が最後の引数としてモックを受け入れるように変更する必要があります。
code: python
@patch('os.path.abspath')
def test_get_info(abspath_mock):
filename = 'somefile.ext'
original_path = '../{}'.format(filename)
test_abspath = 'some/abs/path'
abspath_mock.return_value = test_abspath
fi = FileInfo(original_path)
assert fi.get_info() == (filename, original_path, test_abspath)
ご覧のように、patch デコレーターは、関数全体に対する大きな with 文のように動作します。明らかに、この方法では、対象となる関数 os.path.abspath を、関数全体のスコープ内で置き換えることになります。あとは、patch をデコレーターとして使うか、with ブロックの中で使うかは、あなた次第です。
複数のパッチ
複数のオブジェクトにパッチを当てることもできます。たとえば、上記のテストを変更して、FileInfo.get_info()メソッドの結果にファイルのサイズも含まれているかどうかをチェックしたいとします。Pythonでファイルのサイズを取得するには、os.path.getize() 関数を使います。この関数はファイルのサイズをバイト単位で返します。
そこで、今度は os.path.getsize にもパッチを当てる必要がありますが、これは別の patchデコレータで行うことができます。
code: python
@patch('os.path.getsize')
@patch('os.path.abspath')
def test_get_info(abspath_mock, getsize_mock):
filename = 'somefile.ext'
original_path = '../{}'.format(filename)
test_abspath = 'some/abs/path'
abspath_mock.return_value = test_abspath
test_size = 1234
getsize_mock.return_value = test_size
fi = FileInfo(original_path)
assert fi.get_info() == (filename, original_path, test_abspath, test_size)
関数に最も近いデコレータが最初に適用されることに注意してください。デコレータの構文で @をつけるのは、関数をデコレータの出力で置き換えるためのショートカットであることを常に覚えておいてください。つまり、2つのデコレータは
code: python
@decorator1
@decorator2
def myfunction():
pass
つぎのショートカットです。
code: python
def myfunction():
pass
myfunction = decorator1(decorator2(myfunction))
テストコードでは、関数が最初にabspath_mockを受け取り、次にgetize_mockを受け取るのはこのためです。この関数に最初に適用されたデコレータは os.path.abspath のパッチで、abspath_mock と呼ばれるモックが追加されます。次に os.path.getsize のパッチが適用され、これは独自のmockを追加します。
このテストをパスさせるコードは
code: python
class FileInfo:
def get_info(self):
return self.filename, self.original_path, os.path.abspath(self.filename), os.path.getsize(self.filename)
上記のテストは、2つのwith文を使って書くこともできます。
code: python
def test_get_info():
filename = 'somefile.ext'
original_path = '../{}'.format(filename)
with patch('os.path.abspath') as abspath_mock:
test_abspath = 'some/abs/path'
abspath_mock.return_value = test_abspath
with patch('os.path.getsize') as getsize_mock:
test_size = 1234
getsize_mock.return_value = test_size
fi = FileInfo(original_path)
assert fi.get_info() == (filename, original_path, test_abspath, test_size)
しかし、2つ以上のwith文を使用すると、コードが読みにくくなると私は考えていますので、一般的には、限られた範囲に対してのパッチを必要としない場合は、複雑なwithツリーは避けたいと考えています。
不変オブジェクトのパッチング
標準ライブラリの一部はC言語で書かれていますが、それ以外の部分はPython自身で書かれています。
C言語で実装されたオブジェクト(クラス、モジュール、関数など)はインタープリタ間で共有されており、例えばC言語のプログラムにPythonのインタープリタを埋め込むことで実現できます。そのためには、それらのオブジェクトが不変(immutable)であることが必要で、単一のインタープリタから実行時に変更できないようにする必要があります。
この不変性の例として、以下のコードを確認してください。
code: python
>> a = 1
>> a.conjugate = 5
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'int' object attribute 'conjugate' is read-only
ここでは、メソッドを整数に置き換えようとしていますが、これは無意味なことで、それでも私たちが直面している問題を示しています。
この不変性がパッチとどう関係するのでしょうか?パッチが行うことは、実際にはオブジェクトの属性(クラスのメソッド、モジュールのクラスなど)を一時的に置き換えることであり、もしそのオブジェクトが不変であれば、パッチの動作は失敗します。
この問題の典型的な例は、datetimeモジュールです。datetimeモジュールは、定義上、時間関数の出力が時間的に変化するため、パッチを当てるのに最適な候補の一つでもあります。
この問題を、操作を記録する簡単なクラスで示してみましょう。このクラスは次のようなものです(logger.pyというファイルに入れることができます)。
code: python
import datetime
class Logger:
def __init__(self):
self.messages = []
def log(self, message):
self.messages.append((datetime.datetime.now(), message))
これは非常に単純なことですが、log() メソッドが実際の実行時間に依存した結果を生成するため、このコードのテストには問題があります。
datetime.datetime.now にパッチを当てるテストを書こうとすると、苦い思いをすることになります。これがテストコードで、tests/test_logger.py に入れることができます。
code: python
from unittest.mock import patch
from logger import Logger
def test_init():
l = Logger()
assert l.messages == []
@patch('datetime.datetime.now')
def test_log(mock_now):
test_now = 123
test_message = "A test message"
mock_now.return_value = test_now
l = Logger()
l.log(test_message)
と表示され、pytestを実行すると次の例外が返されますが、これはまさに不変性の問題です。
TypeError: can't set attributes of built-in/extension type 'datetime.datetime'
この問題に対処する方法はいくつかありますが、いずれも、不変のオブジェクトをインポートしたりサブクラス化したりすると、得られるのは可変型の「コピー」であるという事実を利用しています。
この場合の最も簡単な例は、datetimeモジュールそのものです。test_log() 関数では、datetime.datetime.nowオブジェクトに直接パッチを当てようとし、内蔵されているdatetimeモジュールに影響を与えます。しかし、logger.pyファイルはdatetimeをインポートしているので、datetimeはloggerモジュールのローカルシンボルになります。これがまさに今回のパッチの鍵となります。このコードを次のように変更してみましょう。
code: python
@patch('logger.datetime.datetime')
def test_log(mock_datetime):
test_now = 123
test_message = "A test message"
mock_datetime.now.return_value = test_now
l = Logger()
l.log(test_message)
今、テストを実行してみると、パッチが機能しているのがわかります。私たちが行ったのは、datetime.datetime.now の代わりに logger.datetime.datetime をパッチすることでした。このようにして、今回のテストでは2つのことが変わりました。まず、logger.py ファイルでインポートされたモジュールにパッチを当てており、Pythonインタープリタでグローバルに提供されるモジュールにはパッチを当てていません。第二に、logger.py ファイルでインポートされているものですから、モジュール全体にパッチを当てなければなりません。logger.datetime.datetime.now にパッチを当てようとすると、それがまだ不変であることがわかります。
この問題を解決するもう一つの方法は、不変オブジェクトを呼び出し、その値を返す関数を作成することです。この最後の関数は、内蔵されたオブジェクトを使用するだけなので、不変ではないため、簡単にパッチを当てることができます。しかし、この解決策は、テストができるようにソースコードを変更する必要があり、望ましいものとは程遠いものです。もちろん、コードに小さな変更を加え、それをテストしないままにしておくよりは、テストしてもらった方が良いのですが、私は可能な限り、テストがなければ必要のないコードを導入するような解決策は避けています。
最後に
Python のテストに関するこの小さなシリーズの第 2 部では、パッチングメカニズムを見直し、その微妙な点をいくつか実行しました。パッチは本当に効果的なテクニックであり、パッチベースのテストは様々なパッケージで見つけることができます。モックとパッチングは、Pythonや他のオブジェクト指向言語で作業する際の主要なツールの1つになるので、時間をかけて自信を持って使えるようになってください。
いつものように、時間を見つけてmockライブラリの公式ドキュメント を読むことを強くお勧めします。 Python Mocks:やさしい入門編 - パート2